iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0

今天要做什麼?

昨天我們學會了測試結構與組織,但隨著測試越寫越多,你可能遇到一個問題:「為什麼這個測試單獨執行會通過,但和其他測試一起執行時會失敗?」

想像一個場景:你為數學工具庫新增了一個 CalculatorWithHistory 類別,它會記錄計算歷史。第一個測試執行時歷史是空的,測試通過;但第二個測試執行時,歷史裡已經有第一個測試留下的資料,導致測試失敗。這就是「測試污染」問題。

今天我們要學習「測試生命週期」,了解如何在每個測試執行前後進行適當的設置和清理,讓每個測試都能在乾淨、一致的環境中執行。

學習目標

今天結束後,你將學會:

  • 理解測試生命週期的重要性
  • 掌握 beforeEach/afterEach 的使用
  • 學會測試資料的設置和清理
  • 理解測試隔離的概念

TDD 學習地圖

第一階段:打好基礎(Day 1-10)
├── Day 01 - 環境設置與第一個測試
├── Day 02 - 認識斷言(Assertions)
├── Day 03 - TDD 紅綠重構循環
├── Day 04 - 測試結構與組織
├── Day 05 - 測試生命週期 ★ 今天在這裡
├── ...
└── (更多精彩內容待續)

什麼是測試生命週期? 📋

測試執行的階段

每個測試的執行都會經過以下階段:

設置階段 → 執行階段 → 斷言階段 → 清理階段
(Setup)   (Execute)  (Assert)   (Cleanup)
  • 設置階段:準備測試需要的資料和環境
  • 執行階段:呼叫要測試的功能
  • 斷言階段:驗證結果是否符合預期
  • 清理階段:清理測試產生的副作用

沒有生命週期管理的問題

讓我們先看看沒有適當生命週期管理的測試:

// 問題:測試之間會互相影響
let calculator = new CalculatorWithHistory()

it('add operation', () => {
  const result = calculator.add(2, 3)
  expect(result).toBe(5)
  expect(calculator.getHistory()).toHaveLength(1)
})

it('multiply operation', () => {
  const result = calculator.multiply(4, 5)
  expect(result).toBe(20)
  expect(calculator.getHistory()).toHaveLength(1) // ❌ 實際是 2!
})

第二個測試會失敗,因為計算器的歷史記錄還保留著第一個測試的資料。

測試隔離的重要性 🔒

什麼是測試隔離?

測試隔離是指每個測試案例都應該:

  • 獨立執行:不依賴其他測試的執行結果
  • 環境一致:每次執行都有相同的初始狀態
  • 無副作用:執行後不影響其他測試

使用 beforeEach 解決問題

// ✅ 好的測試:每個測試都是獨立的
describe('CalculatorWithHistory', () => {
  let calculator: CalculatorWithHistory
  
  beforeEach(() => {
    calculator = new CalculatorWithHistory()
  })
  
  it('add operation', () => {
    const result = calculator.add(2, 3)
    expect(result).toBe(5)
    expect(calculator.getHistory()).toHaveLength(1)
  })
  
  it('multiply operation', () => {
    const result = calculator.multiply(4, 5)
    expect(result).toBe(20)
    expect(calculator.getHistory()).toHaveLength(1) // 現在會通過
  })
})

beforeEach 的使用 🚀

什麼是 beforeEach?

beforeEach 是在每個測試案例執行「之前」都會執行的函數,用來設置測試環境。

實戰演練:建立 CalculatorWithHistory

建立 src/math/calculatorWithHistory.ts

export class CalculatorWithHistory {
  private history: Array<{
    operation: string
    operands: number[]
    result: number
  }> = []

  add(a: number, b: number): number {
    const result = a + b
    this.recordOperation('add', [a, b], result)
    return result
  }

  multiply(a: number, b: number): number {
    const result = a * b
    this.recordOperation('multiply', [a, b], result)
    return result
  }

  getHistory() {
    return [...this.history]
  }

  getLastResult(): number | null {
    return this.history.length > 0 ? this.history[this.history.length - 1].result : null
  }

  clearHistory(): void {
    this.history = []
  }

  private recordOperation(operation: string, operands: number[], result: number): void {
    this.history.push({ operation, operands, result })
  }
}

建立 tests/day05/calculator-lifecycle.test.ts

import { describe, it, expect, beforeEach } from 'vitest'
import { CalculatorWithHistory } from '../../src/math/calculatorWithHistory'

describe('CalculatorWithHistory', () => {
  let calculator: CalculatorWithHistory
  
  beforeEach(() => {
    // 每個測試開始前都創建一個全新的計算器
    calculator = new CalculatorWithHistory()
  })

  describe('basic_operations', () => {
    it('performs_addition', () => {
      const result = calculator.add(2, 3)
      
      expect(result).toBe(5)
      expect(calculator.getHistory()).toHaveLength(1)
      expect(calculator.getLastResult()).toBe(5)
    })

    it('performs_multiplication', () => {
      const result = calculator.multiply(4, 5)
      
      expect(result).toBe(20)
      expect(calculator.getHistory()).toHaveLength(1) // 現在每個測試都是乾淨的
      expect(calculator.getLastResult()).toBe(20)
    })
  })

  describe('history_functionality', () => {
    it('records_single_operation', () => {
      calculator.add(2, 3)
      
      const history = calculator.getHistory()
      expect(history).toHaveLength(1)
      expect(history[0].operation).toBe('add')
      expect(history[0].operands).toEqual([2, 3])
      expect(history[0].result).toBe(5)
    })

    it('records_multiple_operations', () => {
      calculator.add(2, 3)
      calculator.multiply(4, 5)
      
      const history = calculator.getHistory()
      expect(history).toHaveLength(2)
      expect(history[0].operation).toBe('add')
      expect(history[1].operation).toBe('multiply')
    })

    it('clears_history', () => {
      calculator.add(2, 3)
      calculator.multiply(4, 5)
      expect(calculator.getHistory()).toHaveLength(2)
      
      calculator.clearHistory()
      expect(calculator.getHistory()).toHaveLength(0)
      expect(calculator.getLastResult()).toBe(null)
    })
  })
})

afterEach 的使用 🧹

什麼是 afterEach?

afterEach 是在每個測試案例執行「之後」都會執行的函數,用來清理測試環境。

實戰演練:需要清理的場景

import { describe, it, expect, beforeEach, afterEach } from 'vitest'

describe('cleanup_examples', () => {
  let resource: any
  
  beforeEach(() => {
    resource = { active: false, data: [] }
  })
  
  afterEach(() => {
    // 確保每個測試後都清理資源
    resource.active = false
    resource.data = []
  })

  it('uses_resource_properly', () => {
    resource.active = true
    resource.data.push('test')
    
    expect(resource.active).toBe(true)
    expect(resource.data).toHaveLength(1)
  })
})

資源管理最佳實踐 💡

對稱的設置與清理

始終保持設置(setup)和清理(cleanup)的對稱性:

describe('resource_management', () => {
  let resource: SomeResource
  
  beforeEach(() => {
    resource = new SomeResource()
    resource.initialize()
  })
  
  afterEach(() => {
    resource.cleanup()
  })
})

避免常見陷阱 ⚠️

常見錯誤 ❌

  1. 忘記清理資源:可能導致記憶體洩漏或測試污染
  2. 設置順序錯誤:先初始化再建立物件會導致錯誤
  3. 過度設置:設置不必要的資源浪費時間

今天學到什麼? 📚

今天我們深入學習了測試生命週期的重要概念:

核心概念

  • 測試生命週期:設置 → 執行 → 斷言 → 清理的完整流程
  • 測試隔離:每個測試獨立執行,互不影響
  • beforeEach/afterEach:自動化的設置和清理機制

實用技巧

  • 對稱設置:設置什麼就清理什麼
  • 最小設置:只設置必要的資源
  • 正確順序:先創建資源再初始化

避免的陷阱

  • 忘記清理:導致資源洩露或測試污染
  • 設置順序錯誤:導致初始化失敗

總結 🎯

測試生命週期是確保測試穩定性和可靠性的基礎。通過適當的設置和清理:

  • 提高測試穩定性:每個測試都在乾淨環境中執行
  • 防止測試污染:測試之間不會互相影響
  • 改善偵錯體驗:失敗的測試更容易定位問題

記住:良好的測試生命週期管理是可靠測試的基石。

明天我們將學習「參數化測試」,了解如何用同一個測試邏輯驗證多組不同的資料,讓測試更加高效和全面。


上一篇
Day 04 - 測試結構與組織 🚀
下一篇
Day 06 - 參數化測試 🔢
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言